Repository navigation
fix: report incomplete batch gap-fill analysis - #797
yashrajp22 wants to merge 38 commits into
Conversation
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @yashrajp22, thank you for routing the batch scanner's gap-fill pass through the core batch executor! A failed batch is now retried under the core policy, recorded in the inspection ledger and kept out of gap_fill_applied. Completeness is recomputed with finalize_ledger rather than by editing counters by hand, and the new integration test drives the real retry loop, ledger and report formatters.
Value and readiness: The problem is real on main. I ran main's own run_gap_fill and run_one with malformed JSON, {"findings": 1} and a provider RuntimeError. In each case run_gap_fill returned [] after one call per file with no retry, and run_one returned error=None with gap_fill_applied=True, is_complete=True, execution_successful=True and recommendation SAFE. I then transcribed the PR's gap_fill.py:218-306 and runner.py:778-802 (nothing was imported from the PR) and ran them on main's core, with and without deepseek_compat. Every failure inside a batch is now handled as described:
- Malformed, schema-invalid and BOM-prefixed JSON, and plain-text refusals, get 4 attempts and are then recorded as
llm_structured_response_invalid. - Provider errors are recorded as
llm_batch_failed. - In every failure mode,
gap_fill_applied=False,is_complete=False,SAFEbecomesCAUTION, an error is returned and the command exits 2. - Core findings and findings from successful batches are kept.
- Valid empty responses still succeed after one call.
Two gaps remain against the PR's stated outcome ("Stops failed gap-fill analysis from being marked as successfully applied. Preserves existing findings and reports the failure."), and both block merge:
- A gap-fill failure before batching, such as the pool-mode
TypeError, now replaces the entry with an ERROR stub and drops the core findings thatmainkept (finding 1). - A JSON object without a
findingskey, such as{"error": ...}or{}, is still accepted as a successful, complete pass (finding 2).
The other findings are non-blocking. Once findings 1 and 2 are fixed and CI has run, the PR is ready for final maintainer review. The merge also needs sequencing with #796.
Material findings
- [Blocker]
contrib/batch_scan/runner.py:804: a gap-fill failure before batching now replaces the whole entry with an ERROR stub, which drops the core findings.- The broad
except Exception: return []is gone, soGapFillAnalyzer(...)andget_batches(gap_fill.py:301-302) now raise freely.run_onecatches onlyGapFillError(runner.py:782), so any other exception reachesrunner.py:804-805.entry_from_error(runner.py:811-838) then returnsissues=[], score 0 and severity/recommendationERROR. - Concrete trigger, which is pool mode:
create_api_key_pool_from_envreturns a pool whenever at least 2 keys are configured, fromSKILLSPECTOR_API_KEYSor fromOPENAI_API_KEYplusOPENAI_API_KEY_2..9(api_pool.py:603-664).batch_scan.py:195-197then callsset_api_pool, which installs_pooled_get_chat_model(model=None)(runner.py:89-98).LLMAnalyzerBase.__init__always callsget_chat_model(model=model, timeout=...)(llm_analyzer_base.py:938), so constructingGapFillAnalyzerraisesTypeError: ... unexpected keyword argument 'timeout'.- This affects every non-English skill with
use_llmand a non-empty provider-eligible cache.
- I reproduced this with
main's code, a stub pool anddeepseek_compat, on a Chinese skill using the real graph:main'srun_onekept score 68,HIGH,DO NOT INSTALLand issues E1/PE3/SC2/TM2. It also reportedgap_fill_applied=True, which was the old silent bug.- The transcribed PR
run_onereturned the TypeError text with{score 0, severity ERROR, recommendation ERROR}andissues: []. - The graph itself completes in pool mode. Core LLM analyzers already fail on
mainbut are caught, somain's entry was alreadyis_complete=False. The core findings are what this PR loses.
- Scope:
ValueErrorwas already re-raised onmain, so a missing-credential failure was an ERROR stub before this PR as well. The types whose behaviour changed areTypeErrorand other construction or batching errors, andNotImplementedErrorfromrun_batches_detailed. - Consequence: this fails closed (ERROR, exit 2), so it is not a bypass. But it contradicts "Preserves existing findings and reports the failure" for failures outside
run_batches_detailed. The new test mocksget_chat_modelwithlambda **kw, so the test does not exercise this path. - Expected fix:
- Treat gap-fill exceptions separately from graph exceptions. The simplest way is for
run_gap_fillto wrap construction andget_batchesfailures in aGapFillErrorwhose outcome marks every planned file as failed. The existing branch atrunner.py:790-802then keeps the core entry, marks it incomplete and returns the error. - Decide whether
ValueError(configuration) should stay a hard error. - Add a test in which
GapFillAnalyzerconstruction raises (for example, aget_chat_modelthat rejectstimeout), and assert that TM1 survives. - The pool-mode
timeoutmismatch in_pooled_get_chat_modeland compat Patch 1 is a separate bug onmainand belongs in its own change.
- Treat gap-fill exceptions separately from graph exceptions. The simplest way is for
- The broad
- [Blocker]
contrib/batch_scan/gap_fill.py:260: a JSON object without afindingskey is still accepted as a successful, complete pass.GapFillResult.findingsdefaults to an empty list (gap_fill.py:94), soGapFillResult.model_validate(data)accepts any dict that lacks the key.run_gap_fillthen raises nothing, andrunner.py:788setsgap_fill_applied=True. The PR pins this behaviour incontrib/batch_scan/tests/tests-pro/test_gap_fill.py:226-227.- Transcription on
main's core:{"error": "I cannot help with that"},{"refusal": "..."},{}and a fenced{"error": ...}each gave 1 call,error=None,gap_fill_applied=Trueand 0 findings. A plain-text refusal and{"findings": null}correctly gave 4 calls andllm_structured_response_invalid. - This is not a regression.
mainreturned[]for these inputs as well, and core'sLLMAnalysisResultuses the same default (llm_analyzer_base.py:644). The gap is narrow: gap-fill runs in raw chat mode, so a provider refusal usually arrives as prose, and that case is caught. - Consequence: a non-compliant response that happens to be valid JSON is still reported as "applied, no findings, complete". If the core scan said
SAFE, the skill staysSAFE. That is the silent pass this PR sets out to remove. - Expected fix:
- In
_parse_json_response, raise_StructuredResponseValidationErrorwhennot isinstance(data, dict) or "findings" not in data, or validate against a private model in whichfindingsis required. LeaveGapFillResult's default unchanged. - Change
test_missing_findings_key_keeps_schema_defaultto expect the raise, and add{"other": "value"}and{}to the invalid list. - Trade-off: a bare
{}meaning "nothing found" would then retry and end incomplete. That is defensible because the prompt requires the key (gap_fill.py:147-160).
- In
- [Non-blocking]
contrib/batch_scan/gap_fill.py:199: gap-fill has no deadline. The new structured retries add time inside the 90 s worker budget, and theruntime_limitpath cannot occur in production.super().__init__(base_prompt=prompt, model=resolved_model)passes notimeout, and compat Patch 1_patched_base_init(runner.py:128-136) has notimeoutparameter. So_require_time_remaining()returnsNone(llm_analyzer_base.py:986-991),LLMRuntimeLimitErroris never raised, andRUNTIME_LIMITis never recorded for gap-fill.- The test's
timeoutcase raisesLLMRuntimeLimitError("deadline")from the fake model (tests/test_batch_scan_security.py:188) and sets the retry delays to zero (:195). Both are synthetic. - Retry cost:
- Each malformed batch now gets
STRUCTURED_RESPONSE_MAX_ATTEMPTS=4calls with 0.5/1.0/2.0 s backoff (llm_analyzer_base.py:79-87), wheremainmade 1 call. - Batches run one after another, and a large file can span several batches.
- Transcription with the real delays, 2 malformed files: at L=0.2 s,
maintook 0.41 s for 2 calls and the PR 8.68 s for 8 calls (7.08 s of it backoff). At L=1.0 s, it was 2.01 s against 15.07 s. - That matches N·(4L+3.5 s) against N·L. By arithmetic (not run), 2 batches at L=10 s is about 87 s against 20 s, before core-graph time.
- Each malformed batch now gets
- The worker is killed at 90 s (
batch_scan.py:204,:232-234), and the supervisor substitutes an ERROR stub with no issues. - The missing deadline predates this PR, since
mainalready retried provider and rate-limit errors. What the PR adds is the malformed-output retry cost. Per-request HTTP timeouts and provider errors are correctly recorded asllm_batch_failed, with findings kept. - Consequence: a slow provider that keeps returning malformed output can push a skill past the worker kill, where it would otherwise be reported as incomplete with findings kept. Readers may also take the
timeouttest and the PR body's "runtime limit" wording as covering production timeouts, which they do not. - Expected fix:
- Thread a monotonic deadline through
run_one→run_gap_fill→GapFillAnalyzer, for exampletimeout=lambda: deadline - time.monotonic() - margin. A counterfactual transcription with a 3 s deadline at L=0.5 s stopped after 3 calls in 3.03 s and recordedruntime_limitfor both batches. - This needs Patch 1 to forward
timeout, which #796 adds. - If the fix is deferred, drop "runtime limit" from the PR body's list of handled failures, note the per-batch retry cost, and relabel the synthetic
timeouttest case.
- Thread a monotonic deadline through
- [Non-blocking]
contrib/batch_scan/runner.py:802: every gap-fill incompleteness exits 2, but the JSON shows no error for the non-fatal modes.runner.py:802returnsstr(gap_error)for everyGapFillError.batch_scan.py:468-473prints a per-skillERROR, and:534-535exits 2. The entry never gets anerrorkey, andreports.py:118countsErrors:only fromr.get("error").- Transcription of the PR branch on a real
maingraph result, passed tomain'sreports._format_json/_format_terminal:- json and schema:
execution_successful=True, noskills[].error,failed_executions=0,incomplete_skills=1, row(llm_structured_response_invalid, fatal=False), noErrors:line, exit 2. - timeout: the same, with
(runtime_limit, fatal=False), exit 2. - provider:
execution_successful=False,failed_executions=1,(llm_batch_failed, fatal=True), exit 2.
- json and schema:
- Comparison:
- Core
skillspector scanexits 2 on a result only whenexecution_successfulis False (cli.py:858-859). - Inside
batch_scan,main'srun_onereturnserror=Nonefor core LLM failures. I measured a malformed core output and a fatal core provider failure (failed_executions=1), and both exit 0. - #796 does not change
run_one's error return.
- Core
- The JSON is not completely silent:
gap_fill_applied: false,incomplete_skillsandledger_exceptionsare present. Butgap_fill_applied: falseis also set for an empty cache with no error. - Consequence: a CI job that gets exit 2 finds no per-skill
errorandfailed_executions: 0for two of the three modes, and the same reason code exits differently depending on whether gap-fill or a core analyzer produced it. This fails closed but is inconsistent. - Expected fix: pick one rule and document it.
- Option (a): return an error only when
completeness["execution_successful"]is False. To treat core and gap-fill fatal failures the same way,batch_scan.pywould also countentry.get("execution_successful") is False, which is a gap onmainoutside this PR. - Option (b): keep exit 2 and add a machine-readable field such as
enhancements.gap_fill_status/gap_fill_errorwith the reason codes. - Either way, update the exit-code table in
contrib/batch_scan/docs/README.md:310-316and coordinate with #796.
- Option (a): return an error only when
- [Non-blocking]
tests/test_batch_scan_security.py:133: the new test does not assert two of the behaviours the PR claims.- The parametrized test runs in CI and would fail on
mainin every case. With the test's mocks,main'srun_onereturnserror=Noneandgap_fill_applied=True, so the assertion at:201fails. The core claim is covered. - The assertions (
:201-234) never readentry["risk_assessment"]orentry["execution_successful"], andbatch_scan's exit code depends only onerrors. - Mutation check (transcription): deleting
runner.py:799-801, theexecution_successfulcopy and theSAFE→CAUTIONchange, still passes every assertion in all 8 cases. The recommendation starts asSAFEin this test, so lines 800-801 run but their result is never checked. - There is no case where the failure happens before
run_batches_detailed(finding 1), and thetimeoutcase is synthetic (finding 3). - The edits to
contrib/batch_scan/tests/tests-pro/test_gap_fill.pydo not run in CI:pyproject.toml:117setstestpaths = ["tests"]andMakefile:110runspytest ... tests/. That file pins a BOM-prefixed valid response as a failure (:218) and a missingfindingskey as a valid empty result (:226). - Expected fix:
- Add
assert entry["risk_assessment"]["recommendation"] == "CAUTION"andassert entry["execution_successful"] is (failure_mode != "provider"). - Optionally assert
failed_executions == int(failure_mode == "provider"). - Add a construction-failure case.
- Optionally move the parse-failure unit cases under
tests/.
- Add
- The parametrized test runs in CI and would fail on
- [Non-blocking]
contrib/batch_scan/runner.py:759: docstrings and docs do not reflect the changed contracts.runner.py:756-760says that on failure entry is a stub error entry. For a gap-fill failure,runner.py:802returns the full entry, recomputed and marked incomplete, together with an error.run_gap_fill's docstring (gap_fill.py:276-297) still says it returns "A (possibly empty) list" and has no Raises section. It now raisesGapFillError(:305), and construction errors propagate.- The section header at
gap_fill.py:266still says "Backward-compatible entry point". - The
parse_responsedocstring (:219-224) does not mention the new raise. run_gap_fillis public API (contrib/batch_scan/__init__.py:30,:67), butGapFillError, which a caller needs in order to read.outcome.successful, is not exported.contrib/batch_scan/docs/README.md:205saysgap_fill_appliedis true "if LLM gap-fill was used". On partial success it is now false whilegap_fill_findings> 0.contrib/batch_scan/tests/docs/TEST_GUIDE.md:122still lists 9TestParseResponseInvalidInputtests; there are now 2 (the file goes from 35 to 28 tests).- Expected fix:
- Add Raises:
GapFillError(with.outcome) torun_gap_fill, and updaterun_one's Returns section and the stale header. - Optionally export
GapFillError. - Define
gap_fill_appliedin the README as "true only if every gap-fill batch completed". - Update the TEST_GUIDE row.
- Add Raises:
- [Non-blocking]
contrib/batch_scan/runner.py:784: minor duplication, and a branch reachable only from tests.- The comprehension at
runner.py:784repeatscollect_findings(llm_analyzer_base.py:1512-1525). The runner has no analyzer instance to call it on. - The
isinstance(response, GapFillResult)branch (gap_fill.py:225-226) cannot be reached in production.response_schema = None(:192), so_invoke_batchalways passes astr. The branch exists only to keepTestParseResponsePydanticModelpassing; that test'slen >= 0assertion predates this PR. - Expected fix (optional): expose the retained findings on
GapFillError(for example afindingsattribute) and use it atrunner.py:784. Either tighten the Pydantic-path test to assert the returned rule IDs, or drop the branch and expect the raise.
- The comprehension at
PIC tradeoffs:
- Exit semantics. Exit 2 for any gap-fill incompleteness fails closed but is stricter than core, which exits 2 only on fatal execution failure. Within the same batch run, core-analyzer failures exit 0 (finding 4).
- Surfacing misconfiguration. Removing the broad
except Exceptionexposes real misconfiguration thatmainhid, such as the pool-mode TypeError. Today the cost is losing that skill's core findings (finding 1). - Retry cost. Retrying malformed gap-fill output follows core policy and recovers from transient bad output. It also multiplies calls and latency by up to 4 per batch, with no deadline (finding 3).
- BOM-prefixed JSON. A BOM-prefixed but otherwise valid response is now retried and counted as a failed batch, where
maindropped it silently. This fails closed, and providers rarely emit a BOM. Stripping a leading U+FEFF in_parse_json_responsewould accept it instead. - Empty object. Requiring the
findingskey (finding 2) means a bare{}is no longer accepted as "nothing found". - Report shape. Gap-fill ledger rows appear in
analysis_completenessonly on failure. On success the gap-fill pass is not visible there, so the data shape differs between the two paths. - Merge order with #796. #796 conflicts textually in
contrib/batch_scan/runner.py(theskillspector.llm_analyzer_baseimport line), and both PRs edittests/test_batch_scan_security.py. #796 also makes Patch 1 forwardtimeout, which the deadline fix in finding 3 needs.
Verification and gaps:
- Baseline on
main: reproduced withmain's own code, as described above. Each failure mode made one call, returnederror=None,gap_fill_applied=True,is_complete=Trueandexecution_successful=True, and keptSAFE. - PR behaviour: checked through my own transcriptions of
gap_fill.py:218-306andrunner.py:778-802, run onmain's core with and withoutdeepseek_compat.- A 200k-deep JSON nesting (
RecursionError) givesllm_batch_failed, fatal. - Fenced valid JSON succeeds after 1 call.
_StructuredResponseValidationErroris retried, and provider and runtime errors are not.GapFillAnalyzeroverridesparse_response, so compat Patch 2 does not apply to it.
- A 200k-deep JSON nesting (
- Completeness recompute:
finalize_ledgeron real graph results reproduced the graph's ownanalysis_completenessexactly, on 16 fixtures plus oversized, binary-referenced and >1 MB variants. The PR-style merge adds exactly onegap_fillexception row per failed batch, with nofinding_accounting_errororunaccounted_work, including when a successful batch emits a finding.- Finding IDs are random (
models.py:118-129), so gap findings cannot collide with core IDs. - The worker result stays JSON-serializable, because the reason and outcome codes are
StrEnum.
- Finding IDs are random (
- Tests:
test_gap_fill_failure_is_incomplete_and_preserves_findingsfails onmainin every case. So does the new empty-cache assertion attests/test_batch_scan_security.py:258. Both run in CI throughmake test-ci.- The
tests-prochanges do not run in CI. tests/test_batch_scan_security.pypassesruff checkandruff format --check.
- CI: no checks have run. The workflow run on
46af7b5isaction_required(waiting for a maintainer), so the PR description's test counts are unverified, and CI must pass before merge. - Conflicts: mergeable with
main; themaincommits merged since the review (#691, #577, #608) touch none of these files. It conflicts with open PR #796 incontrib/batch_scan/runner.py, and both edittests/test_batch_scan_security.pyin different places. - Head update: I reviewed
46af7b553b1667402d52d8dc2996f806453b6191. The current heada8fb26641167df4b8eadf71213d928e84bc4e23eonly adds automated merges ofmain(#691, #577, #608) fromupdate-pr-branches.yml; those commits touch none of this PR's files (#691 only routes structured-output binding through an overridable method inllm_analyzer_base.py), so line references tollm_analyzer_base.pybelow are to the current head. The PR's own diff is unchanged (same patch-id), so this review applies to the current head. - I did not run the PR's tests or code, per policy.
- Unverified areas:
- No live provider was available. The recompute was checked against static and no-credential graph results, not against real LLM-analyzer and meta-analyzer ledger rows.
- The 90 s worker kill in finding 3 is arithmetic from measured call counts and backoff, not an observed kill.
- Pool mode was reproduced with a stub pool object.
- Chunked files, where only some chunks of one file fail, were not exercised.
- Local Python is 3.13; CI uses 3.12.
Decision: Changes Requested (reviewed head 46af7b553b1667402d52d8dc2996f806453b6191; current head a8fb26641167df4b8eadf71213d928e84bc4e23e only adds merges of main)
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…res' into yashraj/fix-batch-gap-fill-failures Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…res' into yashraj/fix-batch-gap-fill-failures Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @yashrajp22, thank you for the thorough follow-up. Each of these changes closes an earlier finding directly:
findingsis now a required field.- Construction and
get_batchesfailures are wrapped inGapFillError, with one failure row per planned file. - A single per-skill deadline is threaded from
run_onethroughrun_gap_fillinto the pooled model.
The new setup-failure and real-deadline tests would fail without these changes.
Value and readiness: The gap-fill work is now in good shape. All seven earlier findings are resolved, and I checked each fix by transcribing it onto main's core (details below).
One blocker remains. It comes from the compatibility code this PR copied from #796, not from gap-fill:
- Fixing compat Patch 1 brings the core semantic analyzers back into use in batch mode.
- Compat Patch 2 and Patch 3 are still
main's versions, which turn malformed output into an empty, successful result. - If this PR merges before #796, a statically clean skill whose core LLM reply is malformed or a refusal becomes
SAFE. Onmainthe same skill isCAUTION(finding 1).
The fix is either merge order (rebase onto #796 once it lands) or carrying #796's parser changes. Two non-blocking items remain. CI has not run on this head.
Previous findings:
The thread replies cite 45f3024f. That is a merge commit, and it contains the actual fix commits c8d909f9, b758edd3, 3d8ff228 and fe4dc6d3.
- Resolved. A gap-fill failure before batching replaced the entry with an ERROR stub (formerly
runner.py:804, now:824).- Fix:
run_gap_fillwraps construction andget_batches(gap_fill.py:316-325). It raisesGapFillErrorwith one failure row per cache file:runtime_limitforLLMRuntimeLimitError, otherwisellm_batch_failed._pooled_get_chat_modelnow acceptstimeout(runner.py:90), so the pool-modeTypeErrorthat triggered this finding no longer occurs.- Tests at
tests/test_batch_scan_security.py:673-702cover both stages.
- Evidence (transcription): pool mode on
main's real graph, a Chinese skill, every gap-fill response malformed.run_onereturned "Gap-fill analysis did not complete for 1 batch(es)." It kept 68/HIGH/DO NOT INSTALL and issues E1/PE3/SC2/TM2, setgap_fill_status=incompletewith reasons['llm_structured_response_invalid'], and leftpool.active_requests=0. Onmainthe new setup-failure test would fail, becauserun_gap_fillreturns[]there anderroris None. - Author's reply: accurate for setup failures and for batch failures that
run_batches_detailedrecords. AValueErrorraised during batch execution still escapes; see finding 2.
- Fix:
- Resolved. A JSON object without a
findingskey was accepted as a complete pass (gap_fill.py:267).- Fix:
GapFillResult.findingsis required (gap_fill.py:99)._parse_json_responseraises_StructuredResponseValidationErroron a JSON or validation error (gap_fill.py:265-269).- The CI-run test adds a
missing_findingsmode.
- Evidence (transcription on
main's core):- Each of these took 4 calls and raised
GapFillErrorwith['llm_structured_response_invalid']:{},{"error": ...},{"refusal": ...}, a fenced{"error": ...},{"findings": null},[]and plain text. - Each of these took 1 call and succeeded:
{"findings": []}, a BOM-prefixed valid response, a fenced valid response, and{"findings": [], "error": "x"}.
- Each of these took 4 calls and raised
- Author's reply: accurate.
- Fix:
- Resolved. Gap-fill had no deadline, so
runtime_limitcould not occur in production.- Fix:
run_onecomputesdeadline = start + timeout - min(5, timeout/10)(runner.py:773) and passes a callable timeout torun_gap_fill(:795). That reachesLLMAnalyzerBasethroughgap_fill.py:205.- Patch 1 forwards
timeout(runner.py:132-140). batch_scan.py:143and:210pass the worker timeout down.PooledChatModelbounds slot waits and key retries by the deadline it receives.
- Evidence (transcription):
- Pool mode, L=0.5 s, timeout=6: gap-fill stopped at 5.40 s after 3 calls with
['runtime_limit'], and the core findings were kept. - Without the pool, using the real backoff delays: L=0.2 s and timeout=2 gave 2 calls in 1.80 s with
runtime_limit. - For comparison, the earlier round measured 8 calls in 8.68 s for 2 files at L=0.2 s with no deadline.
- Pool mode, L=0.5 s, timeout=6: gap-fill stopped at 5.40 s after 3 calls with
- Author's reply: accurate. The deadline covers gap-fill only; see finding 3, item 2.
- Fix:
- Resolved. Exit 2 with no machine-readable error.
- Fix: the author chose option (b).
- Exit 2 is kept, and
enhancements.gap_fill_error,gap_fill_status: "incomplete"andgap_fill_error_reasonsare added (runner.py:817-821). 3d8ff228deliberately leavesentry["error"]unset, so the entry stays in the completeness aggregation (reports.py:53).reports.py:348copiesenhancementsintoskills[].- The exit-code row is updated (
contrib/batch_scan/docs/README.md:317).
- Exit 2 is kept, and
- Author's reply: accurate.
- Fix: the author chose option (b).
- Resolved. The test did not assert the recommendation or
execution_successful.- Fix:
- The test now asserts both (
tests/test_batch_scan_security.py:209-210). - It adds setup-failure cases (
:673-702) and a real-deadline case (:705-737). - The synthetic case is relabelled
runtime_signal.
- The test now asserts both (
- Evidence:
- Deleting the
SAFE→CAUTIONchange or theexecution_successfulcopy now fails:209-210. - The deadline test fails without the timeout threading (4 calls) and without Patch 1 forwarding
timeout(llm_batch_failedinstead ofruntime_limit).
- Deleting the
- Author's reply: accurate.
- Fix:
- Resolved. Stale docstrings and docs.
- Fix:
run_gap_fillhas aRaisessection (gap_fill.py:307-311).- The section header is renamed (
:273). run_one's Returns section is updated (runner.py:765-770).GapFillErroris exported fromcontrib/batch_scan/__init__.py.- The README defines
gap_fill_applied(README.md:205-206). - The TEST_GUIDE row is updated.
- Author's reply: accurate. Some new wording nits are in finding 3.
- Fix:
- Resolved. Duplicated findings comprehension, and a Pydantic branch reachable only from tests.
- Fix:
GapFillError.findings(gap_fill.py:107) is used atrunner.py:799.TestParseResponsePydanticModelnow asserts['P5']. - Author's reply: accurate.
- Fix:
- Still open, and now affects behaviour. Merge sequencing with #796.
Earlier tradeoff notes:
- BOM-prefixed JSON: now accepted (
gap_fill.py:253strips U+FEFF), in 1 call. - Bare
{}: no longer treated as "nothing found", as intended. - Report shape: gap-fill ledger rows still appear only on failure. That was a note, not a request.
Material findings
-
[Blocker]
contrib/batch_scan/runner.py:140(Patch 1) together with:147-170(Patch 2) and:187-215(Patch 3). Fixing Patch 1 brings the core semantic analyzers back into use in batch mode, but Patch 2 and Patch 3 still turn malformed output into a clean pass.- Mechanism:
- On
main, compat Patch 1 (_patched_base_init,mainrunner.py:127) has notimeoutparameter. - Since c94b50a (#225) and 73dd1f1 (#393), the core semantic analyzers pass
timeout=(for examplesemantic_security_discovery.py:215). - The batch worker and
main()always run underdeepseek_compat(batch_scan.py:199,:269). So onmain, every core semantic analyzer in batch mode fails construction with aTypeErrorand is reportedunavailable.
- On
- What this PR changes:
- It copies #796's fixes to Patch 1 (
runner.py:132-140) and_pooled_get_chat_model(:90-99), so the core analyzers run again. - The copies match #796 exactly, including
api_pool.py(blobb068f8b0) and five tests. - Patch 2 and Patch 3 are still
main's versions. They return[]onJSONDecodeErroror a schema error (runner.py:160,:170,:200,:215). - With
response_schema=None, that[]counts as a successful batch, so the analyzer reportscompleted.
- It copies #796's fixes to Patch 1 (
- Evidence (transcription): I ran the head's Patch 1 with
main's Patch 2 and Patch 3, onmain's real graph andmain'srun_one, underdeepseek_compat. On an English skill the PR'srun_oneskips gap-fill and differs frommain's only by the unused deadline.- Benign English skill, every core reply
not JSON: - Schema-invalid reply
{"findings":[{"bogus":1}]}: the same split, SAFE under this PR and CAUTION onmain. - Statically clean English skill with exfiltration intent, where the reply is the plain-text refusal "I cannot help with that request.":
- This PR: 3 calls, all
completed, SAFE, no issues. main: CAUTION.- Text in a skill can provoke a refusal, so an attacker can steer this outcome.
- This PR: 3 calls, all
- Malformed meta-analyzer output also reports
completedunder this PR. - Controls:
- A valid
{"findings": []}gives SAFE, which is correct. - A valid SSD-1 finding gives DO NOT INSTALL with score 83 under this PR, against CAUTION with score 0 on
main. - A fixture with static findings is DO NOT INSTALL in all three setups.
- A valid
- Benign English skill, every core reply
- Context:
- Patch 2's silent
return []is not new code. It was the live batch behaviour until Patch 1 broke in August. main's CAUTION is a side effect of that breakage, andmainalso misses every detection that only the LLM analyzers make (see the SSD-1 control).- Bringing the analyzers back is a real improvement for well-formed output. The regression is narrower: malformed or refused output on a statically clean skill used to fail closed and now fails open.
- For non-English skills, a malformed gap-fill reply still gives CAUTION. English skills, which get no gap-fill, are the main exposure.
- Patch 2's silent
- Consequence:
- If this PR merges before #796, LLM-enabled batch scans report malformed or refused core-analyzer output as a complete, finding-free semantic pass.
- A statically clean skill is then rated SAFE where
mainrates it CAUTION. That is the same silent pass this PR removes from gap-fill, reintroduced in the core analyzers. - The PR body does not call out the Patch 1 fix or this behaviour change. The live checks in the body ran across the updated PRs together, so they would not catch a standalone merge.
- Expected fix:
-
Preferred: merge #796 first, rebase this PR onto it, and drop the duplicated hunks:
api_pool.py_pooled_get_chat_model- Patch 1
_patched_chatopenai_init- the
test_monkeypatch_fragility.pyedit - the five duplicated tests:
test_compat_http_timeout_preserves_shorter_deadline,test_pooled_client_bounds_connect_and_disables_sdk_retries,test_pooled_key_retries_share_one_deadline,test_pooled_request_releases_slot_when_client_build_failsandtest_pooled_request_slot_wait_obeys_deadline
Do not drop Patch 1's
timeoutforwarding unless #796 is already in.gap_fill.py:205now passestimeout=, so withmain's Patch 1, constructingGapFillAnalyzerwould fail. The copy follows from the earlier finding 3, which noted that the deadline needs this Patch 1 change. -
If this PR must land first: also carry these parts of #796, so that malformed or refused core output under
deepseek_compatendsdegraded, not SAFE:- its Patch 2 and Patch 3 changes (raise
_StructuredResponseValidationErroron a JSON or schema error) - its
_verify_patch_targetscheck fortimeout - its
test_compat_parse_failures_retry_and_remain_visibleandtest_compat_pooled_constructor_forwards_timeouttests
- its Patch 2 and Patch 3 changes (raise
-
Either way: state in the PR body that core LLM analyzers now run in batch mode.
-
- Mechanism:
-
[Non-blocking]
contrib/batch_scan/gap_fill.py:326: aValueErrorraised during gap-fill batch execution still replaces the entry with an ERROR stub and drops the core findings.-
Path:
- Only construction and
get_batchesare inside thetry(gap_fill.py:316-325).run_batches_detailedat:326is outside it. - Core re-raises
ValueErrorfrom a batch (llm_analyzer_base.py:1373-1374, unchanged). runner.py:797catches onlyGapFillError, so the exception reachesrunner.py:824-825. Thereentry_from_errorreturnsissues=[], score 0 and ERROR.- (
NotImplementedErroris effectively unreachable here, becauseGapFillAnalyzeroverridesparse_response.)
- Only construction and
-
Triggers:
- The callable timeout from
runner.py:795now enables the per-call model refresh in_model_for_callfor gap-fill, and aValueErrorraised there escapes. - More broadly, langchain_openai raises
ValueErrorwhen an OpenAI-compatible endpoint returns HTTP 200 with anerrorbody (langchain_openai/chat_models/base.py:1770-1771). I reproduced this through corerun_batches_detailedwith a realChatOpenAIover a mock transport, and it was re-raised after 1 call. So this is not limited to configuration errors.
- The callable timeout from
-
Transcription: I made
get_chat_modelraiseValueErroron the refresh.- With gap-fill: error "synthetic: credentials unavailable on refresh", risk
{0, ERROR, ERROR}, issues[]. - The same skill without gap-fill keeps 68/HIGH and issues E1/PE3/SC2/TM2.
- With gap-fill: error "synthetic: credentials unavailable on refresh", risk
-
Consequence:
- This is not a regression against
main, which re-raisedValueErrorat every stage, and it fails closed. - But it contradicts
contrib/batch_scan/docs/README.md:439-440("Failed setup and failed batches retain core findings") and the newRaisestext atgap_fill.py:310("Any setup or batch failure"). - The same
ValueErroris also handled differently depending on when it happens: the core findings are kept if it is raised at construction (:317), but replaced by an ERROR stub if it is raised during batching.
- This is not a regression against
-
Expected fix: when
run_batches_detailedraises, its partialBatchExecutionResultis discarded. A plaintryaround:326therefore cannot tell which batches completed. Either:- run the batches one at a time in
run_gap_fill(run_batches_detailed([batch])in a loop), and convert aValueErrorinto aBatchFailurewithllm_batch_failedfor that batch and the remaining ones; or - mark every planned file as failed, and accept losing completed gap-fill findings in that case.
Add a test in which the model's
invoke(orget_chat_modelon its second call) raisesValueError. It should assert that the core issue IDs survive and thatgap_fill_status == "incomplete". If you prefer to keep these errors hard, narrow the README and docstring wording instead. - run the batches one at a time in
-
-
[Non-blocking]
contrib/batch_scan/docs/README.md:437: wording in the new deadline and pool text.- Placement: the new paragraph (
README.md:437-443) is appended after the "Next:" navigation footer at:434. The exit-code section (:311-317) is the natural home for it. - Worker-limit sentence:
:443says the worker hard limit applies "if a provider ignores its request timeout". That is accurate for gap-fill, but incomplete for the worker as a whole.- The deadline at
runner.py:773reaches onlyrun_gap_fill(:795), andgraph.invoke(:777) is not bounded by it. - In a measurement on
main's code, the core analyzers received a timeout of about 600 s (the shared scan budget). - The core analysis runs several LLM stages in sequence, each with up to 4 structured attempts plus pool slot waits. So it can plausibly reach the 90 s kill even when providers behave (not observed).
- The deadline at
- PR body: "Gap-fill and the provider pool use the remaining per-skill deadline" holds only for gap-fill's pooled calls.
PooledChatModeltakes the caller's timeout (runner.py:90-99), and the core analyzers pass about 600 s. - Docstrings: the
run_one(runner.py:745-771) andrun_gap_fill(gap_fill.py:285-312) docstrings do not document the newtimeoutparameter. (api_poolwas already undocumented inrun_gap_fillonmain.) - Test counts:
contrib/batch_scan/tests/docs/TEST_GUIDE.md:42and:117still say "41 tests", and the head has 29. The totals were already stale onmain, which has 35, but this PR edited the same table at:122without updating them.
- Expected fix:
- Move the paragraph into the exit-code section.
- Say that the deadline covers gap-fill only, and that core analysis can still reach the worker limit.
- Correct the pool sentence in the PR body.
- Document
timeoutin both docstrings. - Fix the TEST_GUIDE totals.
- Placement: the new paragraph (
PIC tradeoffs:
- Core analyzers running again in batch mode.
- Fixing Patch 1 makes LLM-only detections work in batch mode again: score 0 to 83 for a valid SSD-1 reply.
- The cost is exposing Patch 2 and Patch 3's silent parsing (finding 1), and adding core LLM time to the 90 s worker window.
- That second cost fails closed (ERROR stub, exit 2), applies equally to #796, and is not fixed by merge order. A real fix would bound core LLM work in batch mode by
run_one's deadline, which is outside this PR's scope.
- Exit 2 for any incomplete gap-fill (option b).
- This fails closed and is now machine-readable.
- The terminal
Errors:count (reports.py:118) still counts onlyentry["error"], which the README explains. - Core-analyzer incompleteness in the same run still exits 0.
- Retries bounded by the deadline.
- Each batch gets up to 4 attempts within the per-skill budget.
- A slow provider that keeps returning malformed output now ends as
runtime_limitwith the findings kept, instead of a worker kill.
- Setup failure marks every cache file as failed. This is conservative but accurate: no gap-fill work happened.
- Copying #796's code. Copying it made the textual conflict mechanical, but it made merge order affect behaviour.
Verification and gaps:
-
Reviewed head:
6fdb5733710554616edb3fa54fe69e657d5aa83c. I confirmed withgh pr viewthat it is still the live head. -
CI: the "CI" workflow run on this head is
action_required(waiting for maintainer approval), and there are 0 check runs. The PR body's test counts (542/571, 29 plus 10 subtests, 6,243) and its live NVIDIA Build checks are unverified. -
Conflicts:
main(5edc347): merges cleanly.- #796 (581ded0): conflicts in
contrib/batch_scan/runner.py(theskillspector.llm_analyzer_baseimport line),contrib/batch_scan/tests/test_monkeypatch_fragility.pyandtests/test_batch_scan_security.py. The merged tree has no duplicate test definitions, so the resolution is mechanical. - #798 (102bec1): conflicts only in
tests/test_batch_scan_security.py, where both PRs append at the end of the file, so the resolution is a union. #798's conflict withmaininsrc/skillspector/cli.pyis unrelated to this PR. - #741, #793, #794 and #795: no textual conflicts.
-
Method: all of this PR's behaviour above comes from my own transcriptions of the head's code, run on
main5edc347:gap_fill.py:96-329,runner.py:90-140and:772-825, andapi_pool.py:478-546- #796's Patch 2
Nothing was imported from the PR.
-
Also checked:
PooledChatModelnow releases its slot when building the client fails, or onCancelledError(api_pool.py:509,:541); onmainthe slot leaked.reports.py:348copies the new enhancement fields intoskills[], and they are JSON-serializable.- Every author commit has a Signed-off-by line. The merges without one are update-branch merges, which
ci.ymlexempts. tests/test_batch_scan_security.pypassesruff checkandruff format --check.asyncio_mode = "auto"(pyproject.toml:118), so the async pool tests run in CI.
-
Gaps:
PooledChatModelwas exercised with a fake_build_llm. I did not measure real httpx timeout enforcement, the multiprocessing manager RPC path, or whether slot polling is starved under contention; that is also #796's code.- That core analysis can reach the 90 s kill comes from code reading plus measured timeout values, not from an observed kill.
- Under the head's Patch 1 in raw mode,
mcp_tool_poisoningraisedNotImplementedError("TP4 requires a structured assessment response"). It was alsofailedonmain, for a different reason. I did not investigate it. - BOM acceptance is tested only in
tests-pro, which CI does not collect. - Local Python is 3.13; CI uses 3.12.
-
I did not run the PR's tests or code, per policy.
Decision: Changes Requested (reviewed head 6fdb5733710554616edb3fa54fe69e657d5aa83c)
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
@yashrajp22 thank you for the gap-fill work, and for waiting while #796 landed first.
Value and readiness
This is a status re-review, and the PR's own diff has not changed since the 2026-10-12 review.
- #796 is not in this head. #796 merged to
mainat 08:16 UTC on 2026-10-13, but this head (0452124b) does not contain it. The last automated merge ofmainran at 06:15 UTC, and later ones stopped because the branch now conflicts withmain. - The blocker is therefore still present at this head. Merge order can no longer trigger it, though, and a correct merge of
mainremoves it. - I simulated that merge, and I expect no blockers once it is pushed.
Material findings
Prior findings (2026-10-12 review):
- Blocker: still open at this head, and resolved by merging
main.contrib/batch_scan/runner.py:140(open thread): the head still pairs the fixed Patch 1 with the old Patch 2 and Patch 3, which silentlyreturn [](runner.py:160,:170,:200,:215).- The simulated merge (
git merge-tree) has exactly three conflicts:- the
llm_analyzer_baseimport atrunner.py:41-42; - one test edit in
test_monkeypatch_fragility.py; - two append regions in
tests/test_batch_scan_security.py.
- the
- Everything else auto-merges to #796's versions: Patch 2 and Patch 3, which raise
_StructuredResponseValidationError(mainrunner.py:151-161and:181-195), thetimeoutguard, Patch 1, the pooled factory andapi_pool.py. - After resolution: the tree differs from
mainonly in the gap-fill changes, and the duplicated hunks and tests drop out. - Required: merge
mainand resolve the conflicts as follows:runner.py: keep both import lines. Do not take this branch's whole side of the file, because that brings back the silent Patch 2 and Patch 3.test_monkeypatch_fragility.py: takemain's side, which supersedes this edit.tests/test_batch_scan_security.py: keep both blocks. No test name ends up duplicated.- Check:
git diff origin/main -- contrib/batch_scan/api_pool.pyshould then be empty.
- The simulated merge (
- Non-blocking, still open.
gap_fill.py:326(open thread): aValueErrorraised during batch execution still drops the core findings. - Non-blocking, still open.
contrib/batch_scan/docs/README.md:437(open thread): the deadline and pool wording, the docstrings and the TEST_GUIDE totals.
Earlier findings 1-7 remain resolved. The old merge-sequencing note is superseded by the conflict described above. The request to state in the PR body that the core analyzers now run in batch mode no longer applies, because #796 made that change on main.
PIC tradeoffs
None new.
Verification and gaps
git merge-base --is-ancestor 7ee43713 0452124bfails, which confirms that #796 is not in this head.- I read the conflicted files in the merge-tree result, and diffed that tree against
main8b993d32. - Since this PR's base, #796 is the only
mainchange tocontrib/batch_scan. - The author has not replied or pushed since the last review. CI is
action_required, and it must run on the post-merge head. - This was a static review. I will re-review the post-merge head.
Decision: Changes Requested (reviewed head 0452124b610e0bcc61ed64ac3da42c18ff602e4b)
…-fill-failures Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com> # Conflicts: # contrib/batch_scan/runner.py # contrib/batch_scan/tests/test_monkeypatch_fragility.py # tests/test_batch_scan_security.py
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Gap-fill failures now leave the core scan findings intact and explicitly report incomplete coverage. Each completed batch contributes its findings; provider exceptions mark the current and remaining batches failed, and a zero-result success remains distinct from an unprocessed batch.
The branch includes main’s #796 fix, so core semantic analyzers run in batch mode and malformed structured replies remain failures. Its duplicated provider-pool changes have been removed from this PR’s diff. The remaining per-skill deadline applies to gap-fill calls; core analysis retains its own workflow budget and the worker retains its outer timeout.
Validation: 459 tests plus 3 subtests passed from source and with a fresh installed core wheel, including a ValueError raised by the model after zero or one successful gap-fill batch. The batch contribution is not packaged in the core wheel, so these checks exercise the contribution source against that installed package. The pool’s deadline regression also has a bounded test timeout.
The full contribution test suite also passes 161 tests plus 13 subtests in each mode using synthetic credentials with networking disabled. Its import-isolation test now uses the correct repository directory and preserves the selected source or installed-package path.